Repository navigation
fix(runtime): MCP list_actions marks a confirmation-gated action unavailable for an AI service account - #33144
Conversation
…navailable for an AI service account Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibxBYPPczsnXa8gB52y
…ble for an AI service account An AI service account never confirms an action for itself, so run_action refuses it on every action declaring ai.requiresConfirmation: true. The listing now carries that refusal's code and sentence on the row, ahead of the forward-credential mark, in the order run_action asks them. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibxBYPPczsnXa8gB52y
📓 Docs Drift CheckThis PR changes 2 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 36 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1527ef40bcea5cafaafa78fa80fa24676b79d95a && git checkout 1527ef40bcea5cafaafa78fa80fa24676b79d95a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bf02d33bd64f496aa8166e1a235ef10977927947 06c6cc6c9fd225ff77363c80ac5e66593e576553 && git checkout -B drift-repro bf02d33bd64f496aa8166e1a235ef10977927947 && git merge --no-ff 06c6cc6c9fd225ff77363c80ac5e66593e576553
node scripts/docs-audit/affected-docs.mjs --json bf02d33bd64f496aa8166e1a235ef10977927947
|
|
domain:server#7 · os-tesla · session_01L9qibzuZJBQY4wUQFJbkfx Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
|
domain:server#7 · os-tesla · session_01L9qibzuZJBQY4wUQFJbkfx 维护者速读 · PR #33144 @
|
Requested by Tesla · project thread
Fixes #33067
Clause-②: yes (widening)
Metadata-protocol: none
维护者速读
list_actions给 AI 服务账号列出声明了ai.requiresConfirmation: true的动作时,在该行标上unavailable,带run_action实际会回的 428 代码与原句。run_action每次都拒;但列表说它可用,无人值守的 agent 据此规划,注定失败。packages/runtime/src/domains/mcp.ts的listActions标记处(新增 confirmation 判定与??顺序);packages/runtime/src/action-confirmation-gate.test.ts末尾新 describe。pnpm --filter @objectstack/runtime exec vitest run src/action-confirmation-gate.test.ts,预期 18 passed。What changes
listActions(packages/runtime/src/domains/mcp.ts) now asks, for a callerisAiServiceAccountCalleris true of, the run door's ownactionConfirmationRefusal, and marks the rowunavailable: { code, reason }with that refusal's code (ACTION_CONFIRMATION_REQUIRED) and sentence ("… An AI service account does not confirm an action for itself: a person runs this action."). Both predicates were already exported; nothing inaction-execution.ts/action-dispatch.tschanged and the rule is not restated.ai.requiresConfirmationflag (the predicate reads=== trueonly), neveractionLooksDestructive.action-dispatch.ts, the CONFIRMATION GATE block before the subject load), and the forward-credential refusal only later, insideforwardApiActionin the dispatch arms. So where both apply the mark is the confirmation refusal (confirmation ?? apiForwardCredentialRefusal(...)).onBehalfOfset) and the system context are not asked the gate at all: their listing is byte-for-byte as before.McpActionSummary.unavailableJSDoc (packages/mcp/src/mcp-http-tools.ts) named only the endpoint case andUNAUTHENTICATED; it now names the account case and its code. Surface note for the seat: this JSDoc is the exported description of whenunavailableappears, the same family as the tool description the order lists; I read it as inside the "description" row. No other text changed.list_actionstool description ("An action that carries "unavailable" cannot be run with the credential you connected with … unavailable.reason says why and what would let it run") stays true for a service-account key, so it is unchanged and noSkills-docs:line is owed. The served skill (skill-md.ts+ Console mirror) does not mentionunavailable.stdio-data-bridge.tsimplements nolistActions; the stdio server registerslist_actionsthrough the sameregisterActionToolsover whatever bridge the host passes, so there is no second listing to align.docs/adr/0151-ai-service-account-a-headless-agent-is-a-registered-principal-of-its-own.mdline 74: "An action that asks for a person's confirmation before it runs (ADR-0011'sai.requiresConfirmation, rule 3's co-sign) is refused for an account, and never confirmed by the account itself". Ruling grep (ruling|Ruling-ref|maintainer|⛔) over the edited spans oflistActions,actionConfirmationRefusal/isAiServiceAccountCallerand theMcpActionSummary.unavailableJSDoc: 0 hits (file-wide: mcp.ts 17, action-execution.ts 65, mcp-http-tools.ts 39, none in or governing these spans).Tests
New top-level
describeinpackages/runtime/src/action-confirmation-gate.test.ts(the confirmation gate's shared harness), each case pairing the listing withrun_actionon the same bridge (HttpDispatcher.buildMcpBridge):unavailableequal to{ code, reason }of the refusal;run_actionwith and without the confirmation member throws that exact code (428) and message; no handler ran and no record was read.mode: 'delete'row (listing still saysrequiresConfirmation: true), a declared-falsedanger row and a plain row stay bare for the account, and the destructive-looking one runs.apiaction under an API key:run_actionanswersACTION_CONFIRMATION_REQUIREDand the mark equals it. CONTROL: a person on the same key keeps theUNAUTHENTICATEDforward-credential mark.Measured first at
origin/main(dd4ce50b, test commitf6f2fa19before the fix): cases 1 and 3 red: the gated row had nounavailable(expected undefined to deeply equal { …(2) }), and for the combined case the listing saidUNAUTHENTICATEDwhilerun_actionansweredACTION_CONFIRMATION_REQUIRED(the run half of case 3 passed, so the 428 was measured).Ablations (each through `scripts/ablation-replace.mjs`, anchor 1 → 0, restored to the HEAD blob, `git diff HEAD` empty)
const refusal = apiForwardCredentialRefusal(...)): 2 failed / 16 passed (cases 1 and 3).const confirmation = true ? …): 2 failed / 16 passed (cases 3 and 4: the person and delegated agent get a marker).apiForwardCredentialRefusal(...) ?? confirmation): 1 failed / 17 passed (case 3).The suite imports
./http-dispatcher.jsfromsrc, so nodist/rebuild sits between mutation and run.Acceptance notes
requiresConfirmationon the row is stillactionLooksDestructive(declared flag, elsemode: 'delete'/variant: 'danger'); only theunavailablemarker follows the declared flag. A row can therefore sayrequiresConfirmation: truewith no marker for an account: that action runs for it (case 2 pins it).run_actionpasses to the gate; not pinned separately here.Verification
At head
06c6cc6c(fixd2b01d7c+ merge oforigin/main02f55c5d), afterpnpm install --frozen-lockfileand a rebuild of the@objectstack/runtimeclosure:Package suites, typecheck, lint
pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2: Test Files 600 passed (600), Tests 11936 passed | 19 skipped (VERDICT command-exit 0).pnpm --filter @objectstack/mcp exec vitest run --maxWorkers=2: Test Files 63 passed (63), Tests 1104 passed (VERDICT command-exit 0).pnpm --filter @objectstack/runtime --filter @objectstack/mcp typecheck: exit 0.pnpm lint: exit 0.Derived gates (dispatch-gates --commands, 66 families; reconciled with --ran)
node scripts/check-plugin-teardown-shape.mjs --self-test(its pinned positive-control commit is not reachable from this clone),pnpm check:dual-build-cjs-loads(needs every package'sdist/).pnpm check:dts-closure: exit 1, because the local closure was built withOS_SKIP_DTS=1, so 39 packages have no.d.ts; environmental, not this diff (no export changed). CI's build lanes carry all three.dispatch-gates --ran: 66 derived, 64 run, 2 NOT-MEASURED (derived from recorded exit 3), 0 UNRUN.Generated by Claude Code